package game.square;

import game.square.temperature.Temperature;
import game.square.temperature.TemperatureScale;

import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import be.kuleuven.cs.som.annotate.Basic;
import be.kuleuven.cs.som.annotate.Immutable;
import be.kuleuven.cs.som.annotate.Raw;

/**
 * A class of teleportation squares, as a special kind of square from where
 * you can teleport to certain teleportation destinations.
 * 
 * @invar 	..
 * 		  | areValidTeleportationDestinations(getTeleportationDestinations())
 * @invar 	The humidity of each teleportation square must be a legal value.
 * 		  | canHaveAs(getHumidity())
 * @invar	The borders of this square are proper borders.
 * 		  | hasProperBorders()
 * 
 * @version 1.0
 * @author Chris Desomer, Geert Heyman
 */
public abstract class TeleportationSquare extends SquareImpl implements RoomSquare {
	
	/**
	 * Initialize this new teleportation square with given temperature and humidity.
	 *
	 * @param	temperature
	 *          The temperature for this new teleportaion square.
	 * @param	temperatureScale
	 * 			The temperature scale in which the given temperature
	 * 			is expressed.
	 * @param 	humidity
	 * 			The humidity for this new teleporation square.
	 * @param	slipperyMaterial
	 * 			Indicates if this new teleportation square is made of a slippery material.
	 * @param	possibleTeportationDestionation
	 * 			Indicates if this new teleporation square is a possible 
	 * @pre		..
	 * 		  | teleportationDestinations != null
	 * @pre		..
	 * 		  | areValidTeleportationDestinations(teleportationDestinations)
	 * @pre		..
	 * 		  | temperatureScale != null
	 * @pre		..
	 * 		  | humidity != null
	 * @effect	..
	 * 		  | super(slipperyMaterial, true)
	 * @post	..
	 * 		  | getTeleportationDestinations().equals(teleportationDestinations)
	 * @post	..
	 * 		  | new.setTemperature(new Temperature(temperature, -200, 5000, temperatureScale))
	 * @post	..
	 * 		  | new.setHumidity(humidity)
	 * @note 	The given temperature scale must correspond to the given
	 * 			temperature.
	 */
	@Raw
	public TeleportationSquare(int temperature, TemperatureScale temperatureScale,
				BigDecimal humidity, boolean slipperyMaterial, List<Square> teleportationDestinations){
		super(slipperyMaterial,true);
		assert(temperatureScale != null);
		assert (teleportationDestinations != null);
		assert (areValidTeleportationDestinations(teleportationDestinations));
		assert (humidity != null);
		setTemperature(new Temperature(temperature, -200, 5000, temperatureScale));
		setHumidity(humidity);
		setTeleportationDestinations(teleportationDestinations);
	}
	
	/**
	 * Return the humidity of this teleportation square.
	 */
	@Basic
	@Override
	public BigDecimal getHumidity() {
		String humidity = this.humidity.toString();
		MathContext mc = new MathContext(this.humidity.precision(),RoundingMode.HALF_EVEN);
		return new BigDecimal(humidity, mc);
	}
	
	/*
	 * An auxiliary method to get the humidity needed in the required precision.
	 */
	private static int getRequiredPrecision(BigDecimal humidity) {
		if (humidity.compareTo(BigDecimal.valueOf(100)) == 0 )
			return getHumidityScale() + 3;
		if (humidity.compareTo(BigDecimal.valueOf(9)) != -1)
			return getHumidityScale() + 2;
		return getHumidityScale() + 1;
	}

	/**
	 * Return the number of decimals used to represent the value of the humidity 
	 * of a square.
	 */
	@Basic
	@Immutable
	public static final int getHumidityScale() {
		return HUMIDITY_SCALE;
	}

	/**
	 * A class variable that stores the number of decimals 
	 * of the value of the humidity of a square
	 */
	private static final int HUMIDITY_SCALE = 2;

	
	/**
	 * Return the temperature of this teleportation square.
	 */
	@Basic
	@Override
	public Temperature getTemperature() {
		return temperature;
	}

	/*
	 * Set the teleportation destinations for this square.
	 */
	private void setTeleportationDestinations(List<Square> teleportationDestinations){
		assert (areValidTeleportationDestinations(teleportationDestinations));
		this.teleportationDestinations = teleportationDestinations;
	}
		
	/**
	 * Get the teleportation destinations.
	 */
	@Basic
	public List<Square> getTeleportationDestinations(){
		return new ArrayList<Square>(teleportationDestinations);
	}
	
	/**
	 * Add the given teleportation destination to the teleportation destinations
	 * of this teleportation square.
	 * 
	 * @param 	destination
	 * 			The destination square to add.
	 * @pre		..
	 * 		  |	destination != null
	 * @pre		..
	 * 		  | destination.isPossibleTeleportationDestination()
	 * @post	.. 
	 * 		  | getTeleportationDestinations().contains(destination)
	 */
	public void addTeleportationDestination(Square destination){
		assert (destination != null);
		assert (destination.isPossibleTeleportationDestination());
		teleportationDestinations.add(destination);
	}
	
	/**
	 * Check if this teleportation square has teleportation target locations.
	 * 
	 * @return 	True if this teleportation square has at least one teleportation
	 * 			destination.
	 */
	public boolean hasTeleportationDestination() {
		return !teleportationDestinations.isEmpty();
	}
	
	/**
	 * Check if the given teleportation destinations are valid teleportation destinations.
	 * 
	 * @param 	teleportationDestinations
	 * 			The teleportation destinations to check.
	 * @return	True if and only if all the teleportation destinations are possible
	 * 			teleportation destinations.
	 */
	@Raw
	public static boolean areValidTeleportationDestinations(
			List<Square> teleportationDestinations) {
		if (teleportationDestinations == null)
			return false;
		Iterator<Square> it = teleportationDestinations.iterator();
		while (it.hasNext()){
			if (!it.next().isPossibleTeleportationDestination())
				return false;
		}
		return true;
	}
	
	/**
	 * Variable that references a set of teleportation destinations of this
	 * teleportation square.
	 */
	private List<Square> teleportationDestinations;
	
	/**
	 * Variable registering the temperature of this square in degrees Celcius.
	 */
	private Temperature temperature;
	
	/**
	 * Variable registering the humidity of this square.
	 */
	private BigDecimal humidity;
	
	/**
	 * Get a randomly picked teleportation destination of this teleportation
	 * square.
	 *
	 * @return	A randomly picked teleportation destination of this teleportation
	 * 			square.
	 */
	public Square teleport(){
		int nbDestinations = teleportationDestinations.size();
		int randomIndex = Math.round((float)Math.random()*(nbDestinations-1));
		return teleportationDestinations.get(randomIndex);
	}

	/**
	 * Set the humidity of this square to the given humidity.
	 * 
	 * @param 	humidity
	 *          The humidity that represents the humidity of this square, 
	 *          before transition to a new equilibrium starts.
	 * @post  	..
	 *        | For each square in getRoom()
	 *        | 	if( square.getHumidity().compareTo(getHumidity()) == -1) 
	 *        |				square.getHumidity().compareTo((new square).getHumidity()) == -1 
	 *        |				&&					
	 *        |				(new square).getHumidity().compareTo(humidity)== -1					
	 * @post	..
	 * 		  | For each square in getRoom()
	 *		  | 	if( square.getHumidity().compareTo(humidity) == 1 )
	 *        |				square.getHumidity().compareTo((new square).getHumidity()) == 1 
	 *        |				&&					
	 *        |				(new square).getHumidity().compareTo(humidity) == 1
	 */
	@Raw
	public void setHumidity(BigDecimal humidity) {
		assert (canHaveAsHumidity(humidity));
		Set<RoomSquare> partOfRoomInEquilibrium = getRoom();
		partOfRoomInEquilibrium.remove(this);
		this.humidity = humidity.round(new MathContext(getRequiredPrecision(humidity)));
		Set<RoomSquare> otherPartOfRoom = new HashSet<RoomSquare>();
		otherPartOfRoom.add(this);
		BigDecimal newHumidity = calculateNewHumidity(otherPartOfRoom,partOfRoomInEquilibrium);
		this.humidity = newHumidity.round(new MathContext(getRequiredPrecision(newHumidity)));
		for (Square square : partOfRoomInEquilibrium){
			TeleportationSquare telSquare = (TeleportationSquare)square;
			telSquare.humidity = newHumidity.round(new MathContext(getRequiredPrecision(newHumidity)));	
		}
	}
	
	/**
	 * Set the humidity of this teleportation square to the given humidity.
	 *
	 * @param 	humidity
	 * 			The new value for the humidity
	 * @pre 	The given humidity must be a valid humidity for a square
	 * 		  | isValidHumidity(humidity)
	 * @pre		The given humidity must be some kind of average of the humidities of
	 * 			all the squares in the room with this square.
	 * @post  	The humidity of this square is equal to the given humidity 
	 * 			rounded with a precision of the number of decimals for the humidity
	 * 			plus one, when the value of the given humidity is less than or equal
	 * 			to 9 and rounded with a precision of of the number of decimals for the 
	 * 			humidity plus two when the value of the given humidity is greater than
	 * 			9.
	 *        | new.getHumidity() == humidity.round(new MathContext(getRequiredPrecision(humidity)))
	 */
	public void setEquilibriumHumidity(BigDecimal humidity) {
		assert (humidityIsSomeAverageOfRoom(humidity));
		this.humidity = humidity;
	}
	
	/**
	 * Check that the given humidity is some kind of average
	 * of the humidities of all the squares in the room with this square.
	 * 
	 * @return	True if and only if the given humidity lies between
	 * 			the minimum and maximum humidity in this room.
	 * @note	When the room has reached equilibrium, all humidities are the
	 * 			same, so the given humidity must be equal to the humidity of
	 * 			the room.
	 */
	public boolean humidityIsSomeAverageOfRoom(BigDecimal humidity) {
		RoomSquare nextRoomSquare;
		BigDecimal highestHumidity = BigDecimal.ZERO;
		BigDecimal lowestHumidity = BigDecimal.ZERO;
		Iterator<RoomSquare> itRoomSquares = getRoom().iterator();
		if (itRoomSquares.hasNext()){
			nextRoomSquare = itRoomSquares.next();
			BigDecimal[] extremeHumidities = getExtremeHumidities(itRoomSquares, nextRoomSquare.getHumidity());
			highestHumidity = extremeHumidities[2];
			lowestHumidity = extremeHumidities[1];
					
		}
		return humidity.compareTo(highestHumidity) <= 0 && humidity.compareTo(lowestHumidity) >= 0;
	}
	
	/*
	 * An auxiliary method to get the extreme humidities of all squares in a room.
	 */
	private BigDecimal[] getExtremeHumidities(Iterator<RoomSquare> itRoomSquares, BigDecimal initialHumidity) {
		BigDecimal highestHumidity = initialHumidity;
		BigDecimal lowestHumidity = initialHumidity;
		while (itRoomSquares.hasNext()){
			RoomSquare nextRoomSquare = itRoomSquares.next();
			if (nextRoomSquare.getHumidity().compareTo(highestHumidity) > 0)
				highestHumidity = nextRoomSquare.getHumidity();
			if (nextRoomSquare.getHumidity().compareTo(lowestHumidity) < 0)
				lowestHumidity = nextRoomSquare.getHumidity();
			}
		return new BigDecimal[]{null, lowestHumidity, highestHumidity};
	}
	
	/**
	 * Check if the given value is a valid humidity.
	 * 
	 * @param 	humidity
	 * 			The humidity to check
	 * @return	False if the given humidity is not effective.
	 * 		  | if (humidity == null)
	 * 		  |		then result == false
	 * @result 	False if the value of the given humidity is greater than 100
	 * 		  | if (humidity.compareTo(BigDecimal.valueOf(100)) == 1)
	 * 		  | 	then result == false
	 * @result 	False if the value of the given humidity is negative
	 * 		  | if (humidity.compareTo(BigDecimal.ZERO) == -1)
	 * 		  | 	then result == false
	 * @result 	False if the scale of the given humidity is less than 
	 * 			the humidity scale of all squares, true otherwise.
	 * 		  | result == !(humidity.scale() < getHumidityScale())
	 */
	@Raw
	@Override
	public boolean canHaveAsHumidity(BigDecimal humidity) {
		if (humidity == null)
			return false;
		if (humidity.compareTo(BigDecimal.valueOf(100)) == 1)
			return false;
		if (humidity.compareTo(BigDecimal.ZERO) == -1)
			return false;
		return !(humidity.scale() < getHumidityScale());
	}
	
	/**
	 * Set the temperature of this square to the given temperature.
	 * 
	 * @param 	temperature
	 *          The temperature that represents the temperature of this square, 
	 *          before transition to a new equilibrium starts.
	 * @post  	..
	 *        | For each square in getRoom()
	 *        | 	if( square.getTemperature().getTemperatureValue(TemperatureScale.CELCIUS) < 
	 *        |			temperature.getTemperatureValue(TemperatureValue(TemperatureScale.CELCIUS))
	 *        |				square.getTemperature().getTemperatureValue(TemperatureScale.CELCIUS) <= 
	 *        |						(new square).getTemperature().getTemperatureValue(TemperatureScale.CELCUIS)
	 *        |				&&					
	 *        |				(new square).getTemperature().getTemperatureValue(TemperatureScale.CELCUIS)<=
	 *        |						temperature.getTemperatureValue(TemperatureScale.CELCIUS)       					
	 * @post	..
	 * 		  | For each square in getRoom()
	 *		  | 	if( square.getTemperature().getTemperatureValue(TemperatureScale.CELCIUS) > 
	 *        |			temperature.getTemperatureValue(TemperatureValue(TemperatureScale.CELCIUS))
	 *        |				square.getTemperature().getTemperatureValue(TemperatureScale.CELCIUS) >= 
	 *        |						(new square).getTemperature().getTemperatureValue(TemperatureScale.CELCUIS)
	 *        |				&&					
	 *        |				(new square).getTemperature().getTemperatureValue(TemperatureScale.CELCUIS)>=
	 *        |						temperature.getTemperatureValue(TemperatureScale.CELCIUS)
	 * @throws	IllegalStateException
	 * 			The given temperature is not effective
	 * 		  | temperature == null
	 */
	@Override
	@Raw
	public void setTemperature(Temperature temperature) throws IllegalArgumentException {
		if (temperature == null)
			throw new IllegalArgumentException("The given temperature is not effective.");
		Set<RoomSquare> otherPartOfRoom = getRoom();
		otherPartOfRoom.remove(this);
		this.temperature = temperature;
		Set<RoomSquare> partOfRoomInEquilibrium = new HashSet<RoomSquare>();
		partOfRoomInEquilibrium.add(this);
		int newTemperatureValue = calculateNewTemperature(partOfRoomInEquilibrium, otherPartOfRoom).getTemperatureValue(TemperatureScale.CELCIUS);
		getTemperature().setTemperatureValue(newTemperatureValue, TemperatureScale.CELCIUS);
		for (Square square : otherPartOfRoom){
			square.getTemperature().setTemperatureValue(newTemperatureValue, TemperatureScale.CELCIUS);
		}
	}
	
	/**
	 * Set all squares belonging to the same room as this square, to their equilibrium
	 * value.
	 * 
	 * @param	partOfRoomInEquilibrium
	 * 			The part of the room of this square, where all squares are in equilibrium
	 * 			with this square.
	 * @param	otherPartOfRoom
	 * 			The part of the room of this square, with the squares that aren't in 
	 * 			equilibrium with this square, but are in equilibrium with each other.
	 * @pre		The union of the two given sets contains only the squares of this room.
	 * 		  | getRoom().containsAll(partOfRoomInEquilibrium) && 
	 * 		  |		getRoom().containsAll(otherPartOfRoom) && 
	 * 		  |		(getRoom().size() == partOfRoomInEquilibrium.size() + otherPartOfRoom.size()
	 * @post	If there is at least one other square belonging to the room of this square, then
	 * 			all temperatures and humidities of the squares that belong to the room are 
	 * 			physically equal to each other.
	 * 		  | if (getRoom().size() != 1)
	 * 		  |		then for each square in getRoom()
	 * 		  |			new.getTemperature().hasEqualTemperatureValueAs(new.getTemperature())
	 * 		  |			&& new.getHumidity().compareTo(new.getHumidity()) == 0
	 * 		  |			
	 * @post	If there is at least one other square belonging to the room of this square, then
	 * 			all temperatures and humidities of the squares that belong to the room are set
	 * 			to some sort of average of their current value.
	 * 		  | if (getRoom().size() != 1)
	 * 		  |		then for each square in getRoom()
	 * 		  |			new.getTemperature() == this.getTemperature().calculateWeightedAverageWith(
	 * 		  |						otherPartOfRoom.iterator().next().getTemperature(),
	 * 		  |						partOfRoomInEquilibrium.size(),	otherPartOfRoom.size())
	 * 		  |			&&
	 * 		  |			humidityIsSomeAverageOfRoom(new.getHumidity())
	 */
	public void setRoomEquilibrium(Set<RoomSquare> partOfRoomInEquilibrium, Set<RoomSquare> otherPartOfRoom) {
		assert (getRoom().containsAll(partOfRoomInEquilibrium));
		assert (getRoom().containsAll(otherPartOfRoom));
		assert (getRoom().size() == partOfRoomInEquilibrium.size()+otherPartOfRoom.size());
		if (!otherPartOfRoom.isEmpty()){
			Temperature newTemperature = calculateNewTemperature(partOfRoomInEquilibrium, otherPartOfRoom);
			BigDecimal newHumidity = calculateNewHumidity(partOfRoomInEquilibrium, otherPartOfRoom);
			partOfRoomInEquilibrium.addAll(otherPartOfRoom);
			for (RoomSquare square : partOfRoomInEquilibrium){
				square.getTemperature().setTemperatureValue(newTemperature.
						getTemperatureValue(TemperatureScale.CELCIUS),
						TemperatureScale.CELCIUS);
				square.setEquilibriumHumidity(newHumidity);	
			}
		}
	}

	/*
	 * An auxiliary method to calculate the new temperature of the room
	 */
	private Temperature calculateNewTemperature(Set<RoomSquare> partOfRoomInEquilibrium, Set<RoomSquare> otherPartOfRoom) {
		if (otherPartOfRoom.isEmpty())
			return temperature;
		Temperature temperatureOtherPartOfRoom = otherPartOfRoom.iterator().next().getTemperature();
		return getTemperature().calculateWeightedAverageWith(
				temperatureOtherPartOfRoom, partOfRoomInEquilibrium.size(),
				otherPartOfRoom.size());
	}

	/*
	 * An auxiliary method to calculate the new humidity of the room
	 */
	private BigDecimal calculateNewHumidity(Set<RoomSquare> partOfRoomInEquilibrium, Set<RoomSquare> otherPartOfRoom) {
		if(otherPartOfRoom.isEmpty())
			return humidity;
		BigDecimal partOfRoomInEquilibriumHumidity = getHumidity();
		BigDecimal otherPartOfRoomHumidity = otherPartOfRoom.iterator().next().getHumidity();
		BigDecimal sizeEquilibriumPart = BigDecimal.valueOf(partOfRoomInEquilibrium.size());
		BigDecimal sizeOtherPart = BigDecimal.valueOf(otherPartOfRoom.size());
		BigDecimal totalSize = sizeEquilibriumPart.add(sizeOtherPart);
		return partOfRoomInEquilibriumHumidity.multiply(sizeEquilibriumPart)
				.add(otherPartOfRoomHumidity.multiply(sizeOtherPart))
				.divide(totalSize);
	}

	/**
	 * Return the squares that belong to the same room as this square.
	 * 
	 * @return {squares in Square | square.isInSameRoomAs(this)}
	 */
	public Set<RoomSquare> getRoom() {
		Set<RoomSquare> room = new HashSet<RoomSquare>();
		room.add(this);
		addOtherRoomSquaresTo(room);
		return room;
	}
	
	/**
	 * Add all other room squares to the given room.
	 * 
	 * @param	roomPart
	 * 			The roomPart to add the other room squares to.
	 * @pre		This teleportation square must be an element of the set.
	 * @post	..
	 * 		  | roomPart.equals({roomSquares in RoomSquare | roomSquare.isInSameRoomAs(this)})
	 */
	public void addOtherRoomSquaresTo(Set<RoomSquare> roomPart) {
		assert (roomPart.contains(this));
		Set<RoomSquare> newRoomNeighbors= new HashSet<RoomSquare>();
		for (Border border : getBorders().values()) {
			SquareImpl neighbor = border.getNeighborSquareFrom(this);
			if (neighbor != null && border.isOpenBorder() && !roomPart.contains(neighbor)){
				RoomSquare roomNeighbor = (RoomSquare) neighbor;
				newRoomNeighbors.add(roomNeighbor);
				roomPart.add(roomNeighbor); 
			}
			// trivial case: no open borders with neighbors that aren't already
			// added the list=> newRoomNeighbors is empty.
		}
		for (RoomSquare newNeighbor : newRoomNeighbors) {
			newNeighbor.addOtherRoomSquaresTo(roomPart);
		}
	}
	
	/**
	 * Checks if the given square is reachable from this square. 
	 * 
	 * @param 	square
	 * 			The square to check reachability from.
	 * @return	Return true if the given square borders this square and 
	 * 			the border between them is an open border
	 *		  | if (square.isDirectRoomNeighborFrom(this))
	 *		  |		result == true
	 * @result	Return true if the given square is a teleportation destination
	 * 			from this square.
	 * 		  | if (getTeleportationDestinations().contains(square))
	 * 		  |		result == true
	 * @result	Otherwise return true if the square this square borders 
	 * 			with an openable border or has as one of its teleportation destinations
	 * 			, are in the same room as the given square.
	 * 		  | For someSquare in Square 
	 * 		  |		if (someSquare.isDirectRoomNeighborFrom(this) || 
	 * 		  |				getTeleportationDestinations().contains(someSquare))
	 * 		  |			if (someSquare.canReachSquare(square))
	 * 		  |				result == true
	 * @result	Otherwise, the result is false.
	 * 		  |	else result == false
	 * 	
	 */
	public boolean canReachSquare(Square square){
		Set<Square> squaresBeingChecked = new HashSet<Square>();
		return squareCanReachOther(square, this, squaresBeingChecked);
	}
	
	/*
	 * An auxiliary method to check if a square can reach another square.
	 */
	private static boolean squareCanReachOther (Square square, Square other, Set<Square> squaresBeingChecked){
		if (trivialCheck(square, other))
			return true;
		squaresBeingChecked.add(other);
		Set<Square> squaresToCheck = new HashSet<Square>();
		addSquaresToCheck(other, squaresBeingChecked, squaresToCheck);
		for (Square squareToCheck : squaresToCheck)
			if (squareCanReachOther(square, squareToCheck, squaresBeingChecked))
				return true;
		return false;
	}
	
	/*
	 * An auxiliary method that add squares that need to be checked.
	 */
	private static void addSquaresToCheck(Square other,
			Set<Square> squaresBeingChecked, Set<Square> squaresToCheck) {
		if (other instanceof TeleportationSquare)
			squaresToCheck.addAll(((TeleportationSquare)other).getTeleportationDestinations());
		for (Border border : other.getBorders().values()) {
			Square neighbor = border.getNeighborSquareFrom(other);
			if (neighbor != null && border.isOpenBorder())
				squaresToCheck.add(neighbor);
		}
		squaresToCheck.removeAll(squaresBeingChecked);
	}
	
	/*
	 * An auxiliary method to check the trivial case of the recursive method canReachSquare(Square square).
	 */
	private static boolean trivialCheck(Square square, Square other) {
		if (other.isDirectRoomNeighborFrom(square))
			return true;
		if (other instanceof TeleportationSquare)
			return ((TeleportationSquare)other).getTeleportationDestinations().contains(square);
		return false;
	}
}
